构建现代化的无服务 Go 应用
(给Go开发大全
加星标)
英文:Robert Laszczak, 翻译:Go开发大全 / happyxhw
欢迎阅读本系列的第一篇文章,关于如何用 GCR (Google Cloud Run) 和 Firebase 构建面向业务的 Go 应用程序!在此系列中,我们将向大家展示如何构建长期的易于开发和维护并且充满乐趣的应用程序。
虽然此系列的核心并不过于关注基础架构和实现细节,但我们仍需要一些基础知识以便后续更好的理解,因此在这篇文章中我们首先介绍一些 Google Cloud 的基础工具以此帮助大家学习一些基础知识。
为什么需要 serverless?
k8s 是目前火热的基础架构,但运行一个 k8s 集群需要组建一个强大的运维团队(DevOps),先让我们跳过一个事实,即 DevOps 暂时并不是一种职务。
随着 k8s 的发展,那些以前可以轻易地部署在虚拟机上的应用程序正在逐渐迁移到极其复杂的 k8s 集群中,维持这些集群正常工作并不是一件轻松的事,这需要大量的维护工作。
从另一方面来讲,将应用从虚拟机迁移到容器中可以给予我们在构建和部署过程中更多的灵活性,这样做可以让我们使用自动化工具快速地部署成百上千的微服务,然而成本也是非常高的。
如果存在能够完全托管的集群解决方案,那岂不是更好?🤔
或许你的公司正在使用云服务商提供的k8s托管集群,如果是这样,你或许已经知道即使是完全托管的 k8s 集群仍需要大量的运维支持。
所以为何不选择 serverless ?将大的应用划分为多个互相独立的 Lambdas (Cloud Function,可伸缩的函数即服务)或许是解决服务维护困难的一种好的方式。
但是,等一下,只有这一种构建无服务应用的方式吗?当然不!
Google Cloud Run (GCR)
GCR 的核心思想很简单,你只需要提供 docker 容器,GCR 负责运行。在容器内部你可以运行任何语言开发的应用程序,暴露出 http 或 grpc API 服务端口。你也不局限于同步处理,可以异步发送/订阅消息。
从基础架构来说,这就是你需要的一切,Google Cloud 替你完成所有工作。并且根据流量大小,容器可以自动的就行动态伸缩,这听起来像是一个完美的解决方案?
在实际中并不是如此的简单,有许多文章已经展示了如何使用 GCR,但是它们只是展示了构建应用中的一小部分,很难根据这些来自不同地方的碎片来构建一个完美的项目,我们或许需要一个完整、详细的项目经历来真正学会如何使用 GCR。
但是什么是最重要的?使用最新、最炫的技术并不能保证你的代码在三个月后不会变成难以被后人维护的代码。无服务解决的仅仅是基础设施问题,它并不能让你的代码变得更容易被维护。相反,这些特别的应用最终都可能会变得难以维护。
在本系列文章中,我们创建了一个真实的应用,你可以使用 Terraform 一键部署到 Google Cloud,也可以使用 docker-compose 在本地部署。
不同于其他的文章,我们介绍了一些其他微妙的问题,这些问题从我们的角度来看对于很多 go 项目是非常常见的。从长期来看,这些问题会变得十分重要并且有益于我们对应用进行扩展。
我们已经迷失方向了吗?不,还没!这种方法可以帮助我们理解我们需要解决的问题和什么要的技术可以帮助我们解决这些问题。同时这也是实际中的一种挑战,如果没有问题,那么我们为什么要千方百计的找到解决方法。
方案
在后续的文章中,我们将介绍如何在 Google Cloud 中运行应用。我们并不打算增加新的问题和带来错误的实践 😉 。如果你已经有 go 语言基础,这篇文章对你来说会比较基础。我们保证就算你只是刚刚开始学习 go,你仍可以跟上后面更加复杂的内容。在文章的第一部分之后,我们将重构业务逻辑的部分应用程序, 这部分将变得更加复杂。
本地运行程序
能够在本地运行项目代码是十分重要的,这可以提高我们的开发效率,可以有效的观察到代码更改带来的变化。如果我们的项目涉及到几百个微服务,本地运行它们将变得十分困难,幸运的是现在这个项目仅有5个服务。
我们在 Wild Workouts 项目中创建了 docker-compose 和相关的实时加载的前后端代码。对于前端,我们使用 vue-cli-service serve 工具来管理容器;对于后端,情况会更复杂一点,我们使用 reflex 管理所有容器。
reflex 会监听所有代码的变更,然后重新构建服务。如果你对其中的细节很感兴趣,可以阅读之前的文章:基于 go mod 和实时加载的 golang docker 开发环境 。
需求
项目唯一的需求是:
docker
docker-compose
运行
克隆代码:
git clone https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example.git && cd wild-workouts-go-ddd-example
运行代码:
docker-compose up
当所有 js 和 go 依赖下载完毕后,你将看到包含前端地址的信息,如下所示:
web_1 | $ vue-cli-service serve
web_1 | INFO Starting development server...
web_1 | DONE Compiled successfully in 6315ms11:18:26 AM
web_1 |
web_1 |
web_1 | App running at:
web_1 | - Local: http://localhost:8080/
web_1 |
web_1 | It seems you are running Vue CLI inside a container.
web_1 | Access the dev server via http://localhost:<your container's external mapped port>/
web_1 |
web_1 | Note that the development build is not optimized.
web_1 | To create a production build, run yarn build.
恭喜你,你可以通过 http://localhost:8080/ 访问本地的 Wild Workouts 应用了。
你也可以访问公开的服务 threedotslabs-wildworkouts.web.app。
Wild Workouts 可以干什么?
Wild Workouts 是一个功能完全的项目,当它是一个完整项目时就意味着我们不能找到任何捷径跳过复杂的部分,这就使得我们的文章变得十分长,耐心点。
太长,别看(tl;dr, Too Long; Do Not Read)
Wild Workouts 是一个面向私人健身教练和学员的应用:
健身教练可以设置训练日程:
学员可以设置特定的训练时间:
其他的功能包括:
安排额度(可以安排多少个训练)
取消训练安排:
如果在训练开始前24小时内取消,则将不退回额度
重新安排训练:
如果想在训练开始前24小时内重新安排训练时间,则需要另一个人同意
日历预览
听起来很简单。
前端
如果你对前端不感兴趣,可以调到后端部分。
实话实说,我是一个后端工程师,并不是 js 和前端的专家,但是我们并不能开发一个不包含前端的端对端应用。
在此系列中,我们更加关注后端部分,我将简要介绍一下用到的前端技术,如果你有前端基础,基本上就没有新的知识。如果你想了解更多的细节,你可以去看实际的代码 web/ directory。
OpenAPI (Swagger) 客户端
没有人愿意手动更新 API 文档,保存多个无聊的 JSON 文档是一件非常无聊的事情而且可能会带来反面的效果。OpenAPI 正是利用 specification 生成的 JS HTTP 客户端和 Go HTTP 服务端来解决这一问题的。我们将在后面后端部分进一步深入细节。
Bootstrap
你或许已经了解过 Bootstrap,这是对每一个后端开发者友好的前端 UI 框架。与HTML和CSS打交道是前端工作的一部分,我作为后端工作者表示十分不喜欢HTML和 CSS。幸运的是,它为我们提供了创建应用所需要的几乎全部组件。
Vue.js
再对比过不同的前端框架后,我选择了 Vue.js,对于此项目这是一个非常明智的选择。
在前 jQuery 时代,我以一个全栈开发者的身份开始了自己的职业生涯,现在前端开发工具已经有了巨大的进步,但现在我仍关注于后端。😉
后端
Wild Workouts 的后端包含三个服务:
trainer: 提供管理教练日程的 HTTP、GRPC 接口
trainings: 提供管理学员训练的 HTTP 接口
users: 提供管理额度和用户信息的 HTTP、GRPC 接口
如果一个服务提供了两种不同的类型的 APIs,他们将暴露在不同的进程中。
Public HTTP API
大部分应用程序的操作都是通过 HTTP 接口触发的。我经常听到 Go 初学者询问应该选择哪个框架去创建 HTTP 服务,我总是建议他们在 Go 中不要使用任何框架,像 chi 这样的简单路由已经足够了,chi 提供了轻量的语法来定义 API 支持的 URLs 和方法,在此之下,我们使用 Go 的 http 标准库,对相关的工具(如,中间件)是百分之百兼容的。
对于其他语言来说,不使用框架看起来有点奇怪,比如:Spring、Symfony、Django 或 Express。对于我也一样,在 Go 中使用任何 HTTP 框架将带来更多的复杂性,并且整个项目都依赖此框架。参考 KISS 😉
所有的服务都使用相同的方式运行 HTTP 服务,所以不需要复制三次相同的代码。
func RunHTTPServer(createHandler func(router chi.Router) http.Handler) {
apiRouter := chi.NewRouter()
setMiddlewares(apiRouter)
rootRouter := chi.NewRouter()
// we are mounting all APIs under /api path
rootRouter.Mount("/api", createHandler(apiRouter))
logrus.Info("Starting HTTP server")
http.ListenAndServe(":"+os.Getenv("PORT"), rootRouter)
}
chi 提供了一套内建的 HTTP 中间件,但我们不必局限于它们,所有兼容 Go 的标准库我们都可以使用。
长话短说:中间件可以允许我们在请求执行的前后(http.Request)执行任何操作。使用 HTTP 中间件使得我们构建 HTTP 服务中有了更多的灵活性。我们可以根据我们的目的组合不同的组件来构建 HTTP 服务。
func setMiddlewares(router *chi.Mux) {
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(logs.NewStructuredLogger(logrus.StandardLogger()))
router.Use(middleware.Recoverer)
addCorsMiddleware(router)
addAuthMiddleware(router)
router.Use(
middleware.SetHeader("X-Content-Type-Options", "nosniff"),
middleware.SetHeader("X-Frame-Options", "deny"),
)
router.Use(middleware.NoCache)
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/common/server/http.go
我们已经准备好了相应的框架,现在可以使用它们了。可以调用trainer中的 server.RunHTTPServer:
package main
// ...
func main() {
// ...
server.RunHTTPServer(func(router chi.Router) http.Handler {
return HandlerFromMux(HttpServer{firebaseDB, trainerClient, usersClient}, router)
})
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/main.go
createHandler 需要返回 http.Handler,在我们的案例中,它是通过 oapi-codegen 生成的 HandlerFromMux。
它提供了 OpenAPI specs 定义的请求路径和请求参数。
// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
r.Group(func(r chi.Router) {
r.Use(GetTrainingsCtx)
r.Get("/trainings", si.GetTrainings)
})
r.Group(func(r chi.Router) {
r.Use(CreateTrainingCtx)
r.Post("/trainings", si.CreateTraining)
})
// ...
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/openapi_api.gen.go
# ...
paths:
/trainings:
get:
operationId: getTrainings
responses:
'200':
description: todo
content:
application/json:
schema:
$ref: '#/components/schemas/Trainings'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ...
# 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/api/openapi/trainings.yml
如果你想更改 OpenAPI spec 文件,你需要重新生成 Go 服务端和 JS 客户端,运行:
make openapi
生成的代码中有一部分是 ServerInterface接口,它包含所有的你需要实现的方法,实现服务的功能就是实现此接口:
type ServerInterface interface {
// (GET /trainings)
GetTrainings(w http.ResponseWriter, r *http.Request)
// (POST /trainings)
CreateTraining(w http.ResponseWriter, r *http.Request)
// ...
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/openapi_api.gen.go
下面是 trainings.HttpServer 实现的例子:
package main
import (
"net/http"
"github.com/go-chi/render"
"gitlab.com/threedotslabs/wild-workouts/pkg/internal/auth"
"gitlab.com/threedotslabs/wild-workouts/pkg/internal/genproto/trainer"
"gitlab.com/threedotslabs/wild-workouts/pkg/internal/genproto/users"
"gitlab.com/threedotslabs/wild-workouts/pkg/internal/server/httperr"
)
type HttpServer struct {
db db
trainerClient trainer.TrainerServiceClient
usersClient users.UsersServiceClient
}
func (h HttpServer) GetTrainings(w http.ResponseWriter, r *http.Request) {
user, err := auth.UserFromCtx(r.Context())
if err != nil {
httperr.Unauthorised("no-user-found", err, w, r)
return
}
trainings, err := h.db.GetTrainings(r.Context(), user)
if err != nil {
httperr.InternalError("cannot-get-trainings", err, w, r)
return
}
trainingsResp := Trainings{trainings}
render.Respond(w, r, trainingsResp)
}
// ...
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/http.go
但是 OpenAPI spec 并不是仅仅生成 HTTP 请求路径,更重要的是,它提供了请求和响应的模型。在大多数情况下,模型比API路径和方法更加复杂,自动生成模型可以在 API 变更后节约时间、避免带来 bug 。
# ...
schemas:
Training:
type: object
required: [uuid, user, userUuid, notes, time, canBeCancelled, moveRequiresAccept]
properties:
uuid:
type: string
format: uuid
user:
type: string
example: Mariusz Pudzianowski
userUuid:
type: string
format: uuid
notes:
type: string
example: "let's do leg day!"
time:
type: string
format: date-time
canBeCancelled:
type: boolean
moveRequiresAccept:
type: boolean
proposedTime:
type: string
format: date-time
moveProposedBy:
type: string
Trainings:
type: object
required: [trainings]
properties:
trainings:
type: array
items:
$ref: '#/components/schemas/Training'
# ...
# 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/api/openapi/trainings.yml
// Training defines model for Training.
type Training struct {
CanBeCancelled bool `json:"canBeCancelled"`
MoveProposedBy *string `json:"moveProposedBy,omitempty"`
MoveRequiresAccept bool `json:"moveRequiresAccept"`
Notes string `json:"notes"`
ProposedTime *time.Time `json:"proposedTime,omitempty"`
Time time.Time `json:"time"`
User string `json:"user"`
UserUuid string `json:"userUuid"`
Uuid string `json:"uuid"`
}
// Trainings defines model for Trainings.
type Trainings struct {
Trainings []Training `json:"trainings"`
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/openapi_types.gen.go
Firestore 云数据库
我们已经有了 HTTP API,但是最好的 API 没有数据返回并且不能存储数据也是无用的。如果我们要构建现代的、可扩展的、真正的无服务应用,Firestore 是一个自然的选择,开箱即用。
以欧洲地区为例,Firestore 价格为:
$0.06 每100,000 篇文档读取
$0.18 每100,000 篇写文档
$0.02 每 100,000 篇文档删除
$0.18/GiB 每月
听起来很便宜?
作为对比,最便宜的云 MySQL 实例db-f1-micro(共享 CPU,3GB数据存储)需要 $15.33 每月,非共享 CPU 的高可用实例每月费用高达 $128.21。Firestore 有免费计划,包含1GB 数据存储和每天 20K 文档写入。
Firestore 是 NoSQL 数据库,所以我们不能像 MySQL 那样建立关系型模型,取而代之的是,我们有一个分层的集合系统。在我们的例子中,数据模型很简单,仅有一层集合。
与大部分 NoSQL 数据库不同的是,Firestore 支持事务,即使是同时更新多篇文档。
Firestore 限制
Firestore 最重要的限制可能是每个文档某一时刻只能被一个操作更新,当然你仍可以并行的更新多篇文档。
在你设计数据模型中要考虑这一因素。在某些情况中,你应该考虑批量操作、不一样的文档设计甚至更换其他数据库。如果数据经常更新,或许
key-value 数据库更加适合。
从我的经历来说这并不是一个严重的问题,在大多数情况中,我使用 Firestore 都是更新大量独立的文档,在此项目中,Firestore 没有任何问题。
我注意到 Firestore 需要一些时间预热。换句话说,在项目建立后,你如果想要在一分钟内插入1亿条文档,这可能不行,这或许与内部的某些逻辑有关。
幸运的是,在实际中,这种操作并不常见。
本地运行 Firestore
Firestore 模拟器并不完美 😉,我发现在某些场景中模拟器并不能100%兼容实际的版本。甚至在某些情况下对相同文档的并发读写会导致死锁。但从我的观点来看,模拟器足够用于本地开发。
另一种方案是,建一个独立的 Google Cloud 项目用于本地开发。我倾向于构建一个真正本地,不依赖任何外部服务的开发环境,这样也可以更好配置 CI 环境。
在五月的最后一天,Firestore 模拟器终于提供了 UI 环境。它已经添加到了 docker-compose 中,访问地址为 http://localhost:4000/。在写这篇文章时,子集合仍不能很好的在模拟器 UI 中展示。别担心,这对 Wild Workouts 并不重要 😉。
使用 Firestore
除了Firestore环境不同,代码均可以在本地和生产环境中正常运行。在本地运行Firestore模拟器时,需要在.env文件中设置环境变量FIRESTORE_EMULATOR_HOST=模拟器的主机名 。例如firestore:8787。在生产环境中不需要额外的操作。
firebaseClient, err := firestore.NewClient(ctx, os.Getenv("GCP_PROJECT"))
if err != nil {
panic(err)
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/main.go
下面的例子展示了如何使用 Firestore 客户端查询教练日程,你可以看到我如何使用查询函数只获取设定间隔的日期。
package main
import (
// ...
"cloud.google.com/go/firestore"
// ...
)
// ...
type db struct {
firestoreClient *firestore.Client
}
func (d db) TrainerHoursCollection() *firestore.CollectionRef {
return d.firestoreClient.Collection("trainer-hours")
}
// ...
func (d db) QueryDates(params *GetTrainerAvailableHoursParams, ctx context.Context) ([]Date, error) {
iter := d.
TrainerHoursCollection().
Where("Date.Time", ">=", params.DateFrom).
Where("Date.Time", "<=", params.DateTo).
Documents(ctx)
var dates []Date
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, err
}
date := Date{}
if err := doc.DataTo(&date); err != nil {
return nil, err
}
date = setDefaultAvailability(date)
dates = append(dates, date)
}
return dates, nil
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/firestore.go
Firebase 的库可以序列化任何 struct 的公开属性和map[string]interface,所以并不需要额外的数据映射。总之并没有什么奇怪的事😉。如果你感兴趣,你可以找到完整的关于数据是如何转换的资料 cloud.google.com/go/firestore GoDoc。
type Date struct {
Date openapi_types.Date `json:"date"`
HasFreeHours bool `json:"hasFreeHours"`
Hours []Hour `json:"hours"`
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/openapi_types.gen.go
date := Date{}
if err := doc.DataTo(&date); err != nil {
return nil, err
}
// 完整代码:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/firestore.go
生产环境部署(太长别看)
你可以一键部署你自己版本的 Wild Workouts
> cd terraform/
> make
Fill all required parameters:
project [current: wild-workouts project]: # <----- put your Wild Workouts Google Cloud project name here (it will be created)
user [current: email@gmail.com]: # <----- put your Google (Gmail, G-suite etc.) e-mail here
billing_account [current: My billing account]: # <----- your billing account name, can be found here https://console.cloud.google.com/billing
region [current: europe-west1]:
firebase_location [current: europe-west]:
# it may take a couple of minutes...
The setup is almost done!
Now you need to enable Email/Password provider in the Firebase console.
To do this, visit https://console.firebase.google.com/u/0/project/[your-project]/authentication/providers
You can also downgrade the subscription plan to Spark (it's set to Blaze by default).
The Spark plan is completely free and has all features needed for running this project.
Congratulations! Your project should be available at: https://[your-project].web.app
If it's not, check if the build finished successfully: https://console.cloud.google.com/cloud-build/builds?project=[your-project]
If you need help, feel free to contact us at https://threedots.tech
我们将在后续的文章中进一步介绍部署的细节。
接下来
本文结束,在后续文章中我们将介绍服务之间的 GRPC 通信,此后还将介绍基于 Firestore 的 HTTP 鉴权认证。
相关代码已经在 Github 中,放心去阅读、运行、探索。
部署和基础架构
与此同时,Miłosz 正在编写与部署和基础架构相关的文章,内容包括:
Terraform
Cloud Run
CI/CD
Firebase hosting
此应用有什么问题?
阅读完所有与此应用相关的文章后,我们将开始重构Wild Workouts并且增加新的功能。
我们并不打算用最炫的技术来装饰简历,我们的目标是使用简单的方法解决现在的问题。重构让我们更加清晰地看到解决了什么问题,并且使实现更简洁。或许这其中的某些技术在 Go 中并不是特别有用,谁知道呢😉。
- EOF -
如果觉得本文不错,欢迎转发推荐给更多人。
分享、点赞和在看
支持我们分享更多好文章,谢谢!